'use client'; import { useState, useEffect, useCallback, useRef } from 'react'; import { useParams, useRouter } from 'next/navigation'; import { useAuth } from '@/lib/auth-context'; import { assetsApi, commentsApi, AssetWithComments, Comment, AnnotationData, TranscodeStatus } from '@/lib/api'; import { Avatar } from '@/components/ui/avatar'; import { VideoPlayer } from '@/components/video-player/VideoPlayer'; import { Tool } from '@/components/video-player/AnnotationCanvas'; const API_BASE = process.env.NEXT_PUBLIC_API_URL || ''; const MAX_ANNOTATIONS = 10; const STATUS_CONFIG: Record = { PENDING_REVIEW: { label: 'Pending Review', colorClass: 'text-warning', bgClass: 'badge-warning', dotClass: 'status-dot-pending' }, CHANGES_REQUESTED: { label: 'Changes Requested', colorClass: 'text-warning', bgClass: 'badge-warning', dotClass: 'status-dot-changes' }, APPROVED: { label: 'Approved', colorClass: 'text-success', bgClass: 'badge-success', dotClass: 'status-dot-approved' }, REJECTED: { label: 'Rejected', colorClass: 'text-danger', bgClass: 'badge-danger', dotClass: 'status-dot-rejected' }, }; const TRANSCODE_CONFIG: Record = { PENDING: { label: 'Queued', color: '#94A3B8', bg: 'rgba(148,163,184,0.08)', spinner: false }, UPLOADING: { label: 'Uploading video…', color: '#60A5FA', bg: 'rgba(96,165,250,0.08)', spinner: true }, PROCESSING: { label: 'Transcoding…', color: '#A78BFA', bg: 'rgba(167,139,250,0.08)', spinner: true }, COMPLETED: { label: 'Ready', color: '#34D399', bg: 'rgba(52,211,153,0.08)', spinner: false }, FAILED: { label: 'Transcode failed', color: '#F87171', bg: 'rgba(248,113,113,0.08)', spinner: false }, UNSUPPORTED_CODEC: { label: 'Unsupported codec', color: '#FBBF24', bg: 'rgba(251,191,36,0.08)', spinner: false }, }; function formatTimecode(seconds: number, fps: number = 30): string { if (!seconds || isNaN(seconds)) return '00:00:00:00'; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); const f = Math.round(seconds * fps) % fps; return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}:${String(f).padStart(2,'0')}`; } export default function ReviewPage() { const params = useParams(); const assetId = params.assetId as string; const { token, user } = useAuth(); const router = useRouter(); const [asset, setAsset] = useState(null); const [comments, setComments] = useState([]); const [loading, setLoading] = useState(true); const [currentTime, setCurrentTime] = useState(0); const [panelWidth, setPanelWidth] = useState(380); const [showApproval, setShowApproval] = useState(false); const [updatingStatus, setUpdatingStatus] = useState(false); const [newComment, setNewComment] = useState(''); const [submitting, setSubmitting] = useState(false); const [replyTo, setReplyTo] = useState(null); const [showResolved, setShowResolved] = useState(false); // Drawing state — lifted to page level const [drawMode, setDrawMode] = useState(false); const [drawTool, setDrawTool] = useState('arrow'); const [drawColor, setDrawColor] = useState('#ef4444'); const [pendingStrokes, setPendingStrokes] = useState([]); // The comment we're annotating (null = annotating the main video, not a specific comment) const [annotatingComment, setAnnotatingComment] = useState(null); // Portrait / landscape detection const [isPortrait, setIsPortrait] = useState(false); useEffect(() => { const mq = window.matchMedia('(orientation: portrait)'); setIsPortrait(mq.matches); const handler = (e: MediaQueryListEvent) => setIsPortrait(e.matches); mq.addEventListener('change', handler); return () => mq.removeEventListener('change', handler); }, []); const isDraggingRef = useRef(false); const panelRef = useRef(null); const resizeStartRef = useRef<{ x: number; w: number } | null>(null); // Ref to capture strokes for save callback (avoids closure stale value) const pendingStrokesRef = useRef([]); const annotatingCommentRef = useRef(null); // Keep refs in sync with state useEffect(() => { pendingStrokesRef.current = pendingStrokes; }, [pendingStrokes]); useEffect(() => { annotatingCommentRef.current = annotatingComment; }, [annotatingComment]); const fps = asset?.fps ?? 30; // Derive the current user's project role const currentUserRole = asset?.project.members.find(m => m.user.id === user?.id)?.role; const isProjectAdmin = currentUserRole === 'ADMIN'; const canComment: boolean | undefined = !!(currentUserRole && currentUserRole !== 'VIEWER'); // ── Poll for transcode progress ─────────────────────────────────────────── const isTranscoding = asset?.transcodeStatus === 'COMPLETED'; const pollRef = useRef | null>(null); useEffect(() => { if (isTranscoding) { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } return; } if (pollRef.current) return; pollRef.current = setInterval(async () => { if (!token) return; try { const { asset: updated } = await assetsApi.getStatus(token, assetId); setAsset(prev => prev ? { ...prev, ...updated } : prev); } catch {} }, 2000); return () => { if (pollRef.current) clearInterval(pollRef.current); }; }, [token, assetId, isTranscoding]); // Load asset + comments const loadData = useCallback(async () => { if (!token) return; try { const [{ asset: a }, { comments: c }] = await Promise.all([ assetsApi.get(token, assetId), commentsApi.list(token, assetId), ]); setAsset(a); setComments(c); } catch { router.push('/projects'); } finally { setLoading(false); } }, [token, assetId, router]); useEffect(() => { loadData(); }, [loadData]); // ── Panel resize ───────────────────────────────────────────────────────── const handleMouseMove = useCallback((e: MouseEvent) => { if (!isDraggingRef.current || !resizeStartRef.current) return; const dx = e.clientX - resizeStartRef.current.x; setPanelWidth(Math.max(280, Math.min(600, resizeStartRef.current.w + dx))); }, []); const handleMouseUp = useCallback(() => { isDraggingRef.current = false; resizeStartRef.current = null; document.body.style.userSelect = ''; document.body.style.cursor = ''; }, []); useEffect(() => { window.addEventListener('mousemove', handleMouseMove); window.addEventListener('mouseup', handleMouseUp); return () => { window.removeEventListener('mousemove', handleMouseMove); window.removeEventListener('mouseup', handleMouseUp); }; }, [handleMouseMove, handleMouseUp]); const handleResizeStart = (e: React.MouseEvent) => { e.preventDefault(); isDraggingRef.current = true; resizeStartRef.current = { x: e.clientX, w: panelWidth }; document.body.style.userSelect = 'none'; document.body.style.cursor = 'col-resize'; }; // ── Comment actions ─────────────────────────────────────────────────────── const handleAddComment = async (content: string, timestamp?: number, annotations?: AnnotationData[]) => { if (!token || !content.trim()) return; setSubmitting(true); try { const { comment } = await commentsApi.create(token, assetId, { content: content.trim(), timestamp, annotations, parentId: replyTo?.id, }); if (replyTo) { setComments(prev => prev.map(c => c.id === replyTo.id ? { ...c, replies: [...(c.replies ?? []), comment] } : c )); } else { setComments(prev => [...prev, comment]); } setNewComment(''); setPendingStrokes([]); setReplyTo(null); } catch (err) { alert(err instanceof Error ? err.message : 'Failed to add comment'); } finally { setSubmitting(false); } }; const handleResolve = async (commentId: string, action: 'approve' | 'reject') => { if (!token) return; try { const { comment } = await commentsApi.resolve(token, commentId, action); setComments(prev => prev.map(c => c.id === commentId ? comment : c)); } catch (err) { alert(err instanceof Error ? err.message : 'Failed to update comment'); } }; const handleRequestResolve = async (commentId: string) => { if (!token) return; try { const { comment } = await commentsApi.requestResolve(token, commentId); setComments(prev => prev.map(c => c.id === commentId ? comment : c)); } catch (err) { alert(err instanceof Error ? err.message : 'Failed to request resolve'); } }; const handleDeleteComment = async (commentId: string) => { if (!token) return; if (!confirm('Delete this comment?')) return; try { await commentsApi.delete(token, commentId); setComments(prev => prev .filter(c => c.id !== commentId) .map(c => ({ ...c, replies: c.replies?.filter(r => r.id !== commentId) })) ); } catch { alert('Failed to delete comment'); } }; // ── Annotation actions ───────────────────────────────────────────────────── // User clicks "Add annotation" on a comment — enter draw mode, annotate at current time const handleAddAnnotationClick = (comment: Comment) => { const existingCount = comment.annotations?.length ?? 0; if (existingCount >= MAX_ANNOTATIONS) { alert(`Maximum ${MAX_ANNOTATIONS} annotations per comment.`); return; } setPendingStrokes([]); setAnnotatingComment(comment); setDrawMode(true); }; // Each completed stroke is added to pendingStrokes const handleStrokeComplete = (stroke: AnnotationData) => { setPendingStrokes(prev => { const next = [...prev, stroke]; if (next.length >= MAX_ANNOTATIONS) { setDrawMode(false); } return next; }); }; // Save pending strokes as annotation on the parent comment (no separate reply) const handleSaveAnnotations = () => { const strokes = pendingStrokesRef.current; const parent = annotatingCommentRef.current; if (!token || !parent || strokes.length === 0) { setPendingStrokes([]); setDrawMode(false); setAnnotatingComment(null); return; } setSubmitting(true); setPendingStrokes([]); setDrawMode(false); setAnnotatingComment(null); commentsApi.updateAnnotations(token, parent.id, strokes).then(({ comment }) => { setComments(prev => prev.map(c => c.id === parent.id ? comment : c)); }).catch(err => alert(err instanceof Error ? err.message : 'Failed to save annotation')).finally(() => setSubmitting(false)); }; // Discard pending strokes const handleUndoAnnotations = () => { setPendingStrokes([]); setDrawMode(false); setAnnotatingComment(null); }; // Delete a single annotation from a comment (owner only) const handleDeleteAnnotation = async (commentId: string, remainingAnnotations: AnnotationData[]) => { if (!token) return; try { const { comment } = await commentsApi.updateAnnotations(token, commentId, remainingAnnotations); setComments(prev => prev.map(c => c.id === commentId ? comment : c)); } catch { alert('Failed to delete annotation'); } }; const handleStatusUpdate = async (status: string) => { if (!token) return; setUpdatingStatus(true); try { const { asset: updated } = await assetsApi.updateStatus(token, assetId, status); setAsset(prev => prev ? { ...prev, status: updated.status } : prev); setShowApproval(false); } catch { alert('Failed to update status'); } finally { setUpdatingStatus(false); } }; const handleTimeUpdate = useCallback((time: number) => { setCurrentTime(time); }, []); const handleCommentSeek = useCallback((comment: Comment) => { const time = comment.timestamp ?? 0; setCurrentTime(time); const videoEl = document.querySelector('video') as HTMLVideoElement | null; if (videoEl) { videoEl.pause(); videoEl.currentTime = time; } }, []); const status = asset?.status ?? 'PENDING_REVIEW'; const statusCfg = STATUS_CONFIG[status]; const transcodeCfg = asset ? TRANSCODE_CONFIG[asset.transcodeStatus] : null; const videoUrl = asset?.hlsPath ? `${API_BASE}/uploads${asset.hlsPath}` : asset ? `${API_BASE}/uploads/${asset.filePath}` : ''; const allComments = comments.flatMap(c => [c, ...(c.replies ?? [])]); const visibleComments = showResolved ? comments : comments.filter(c => !c.resolved); // Only main comments (not replies) have annotations that should show on the video const visibleAnnotations = visibleComments.flatMap(c => (c.annotations ?? []).map(ann => ({ annotation: ann, timestamp: c.timestamp ?? 0 })) ); if (loading) { return (
Loading review…
); } if (!asset) return null; return (
{/* ── Top bar ──────────────────────────────────────────── */}

{asset.title}

{asset.project?.name}
{/* Download */} Download
{/* Status selector */}
{showApproval && ( <>
setShowApproval(false)} />
{Object.entries(STATUS_CONFIG).map(([key, cfg]) => ( ))}
)}
{/* ── Body ───────────────────────────────────────────── */} {/* Landscape: side-by-side | Portrait: stacked (video top, comments bottom) */}
{/* Video area */}
{/* Transcode status overlay — shown when video is not ready */} {transcodeCfg && asset.transcodeStatus !== 'COMPLETED' && (
{transcodeCfg.spinner ? (
) : asset.transcodeStatus === 'FAILED' ? (
) : (
)}
{transcodeCfg.label} {asset.transcodeStatus === 'PROCESSING' && asset.transcodeProgress > 0 && ( {asset.transcodeProgress}% )}
{asset.transcodeStatus === 'PROCESSING' && (
)} {asset.transcodeStatus === 'FAILED' && asset.transcodeError && (

{asset.transcodeError}

)} {asset.transcodeStatus === 'UNSUPPORTED_CODEC' && (

{asset.codec ? `Source codec "${asset.codec.toUpperCase()}" — will re-encode to H.264/AAC` : 'Re-encoding to browser-compatible format…'}

)} {asset.transcodeStatus === 'PROCESSING' && asset.codec && (

Converting from {asset.codec.toUpperCase()} → H.264/AAC

)} {asset.transcodeStatus === 'UPLOADING' && (

Video uploaded — queued for processing

)}
)} {/* Keyboard shortcuts */}
Space play/pause seek ±5s UI frame C draw mode Esc exit draw {formatTimecode(currentTime, fps)}
{/* Resize handle — only shown in landscape */} {!isPortrait && (
)} {/* ── Comment panel ─────────────────────────────────── */}
{/* Panel header */}

Comments

{comments.length}
{formatTimecode(currentTime, fps)}
{/* Drawing mode banner */} {drawMode && (
{annotatingComment ? `Drawing annotation on "${annotatingComment.user?.name}" — ${pendingStrokes.length}/${MAX_ANNOTATIONS} strokes` : `Drawing on video — ${pendingStrokes.length}/${MAX_ANNOTATIONS} strokes`}
)} {/* Comment list */}
{visibleComments.length === 0 ? (

No comments yet

Add a comment below or click Add annotation on an existing comment

) : (
{visibleComments.map(comment => ( { setReplyTo(comment); }} onResolve={(action) => handleResolve(comment.id, action)} onRequestResolve={() => handleRequestResolve(comment.id)} onDeleteSelf={() => handleDeleteComment(comment.id)} onDelete={(id) => handleDeleteComment(id)} onAddAnnotation={() => handleAddAnnotationClick(comment)} onDeleteAnnotation={(anns) => handleDeleteAnnotation(comment.id, anns)} /> ))}
)}
{/* New comment / reply input */}
{replyTo && (
Replying to {replyTo.user?.name}
)} {/* Pending strokes indicator */} {pendingStrokes.length > 0 && (
{pendingStrokes.length} stroke{pendingStrokes.length !== 1 ? 's' : ''} ready {annotatingComment ? ` → annotation on "${annotatingComment.user?.name}"` : ' → will be saved as new comment'}
)}
{ e.preventDefault(); if (newComment.trim() || pendingStrokes.length > 0) { handleAddComment(newComment, currentTime, pendingStrokes.length > 0 ? pendingStrokes : undefined); } }} className="flex gap-2" >